Skip to content

ci: replace the Azure DevOps pipeline with GitHub Actions publishing to GHCR - #1

Merged
grinidx merged 7 commits into
mainfrom
ci/github-actions-ghcr
Aug 4, 2026
Merged

ci: replace the Azure DevOps pipeline with GitHub Actions publishing to GHCR#1
grinidx merged 7 commits into
mainfrom
ci/github-actions-ghcr

Conversation

@grinidx

@grinidx grinidx commented Aug 3, 2026

Copy link
Copy Markdown
Member

Replaces the Azure DevOps pipeline with GitHub Actions building and publishing to GHCR, and makes the image multi-architecture.

azure-pipelines.yml needed an ADO agent pool, an ACR service connection and a Dependency-Track instance before it would run at all. None of that is available to someone who clones a public repo. This runs on the repository's own GITHUB_TOKEN with no secrets to configure.

What changed

Three workflows. ci.yml (pull requests, pushes to main) and release.yml (Sundays 03:00 UTC, or manual) are thin entry points; both call the reusable build.yml, which holds every step that touches the image, so the two paths cannot drift.

Native multi-arch. linux/amd64 on ubuntu-24.04, linux/arm64 on ubuntu-24.04-arm, both free and unlimited on public repos. Ten install scripts hardcoded amd64/x86_64/x64; they now source a shared _arch.sh. Every affected tool publishes arm64 Linux artefacts. PowerShell is the one divergence: Microsoft's Ubuntu repo publishes the powershell deb for amd64 only (verified against both binary-amd64 and binary-arm64 indexes), so arm64 installs from the upstream tarball.

Build, test, then push. Each runner builds its platform, loads it, runs tests/run-all-tests.sh inside the real image, and only then pushes by digest. A merge job assembles the manifest list. This is why Docker's own docker/github-builder reusable workflow was not used - it supports neither local loading nor per-platform tests.

CalVer. v2026.08.09, cut by the weekly no-cache rebuild, which also moves :latest. Most of versions.json installs "latest", so SemVer would imply a compatibility contract this image cannot honour. The README says plainly that a tag is a snapshot and the digest is the only stable identifier.

Supply chain. BuildKit SBOM + provenance: mode=max, and actions/attest signs the merged manifest with a short-lived Sigstore certificate. That is why there is no cosign step - actions/attest is Sigstore-backed already, and a second signature over the same digest with the same trust root adds nothing. Trivy reports to the Security tab without gating; this image always carries some upstream HIGHs and blocking would stall the weekly rebuild, leaving users on a strictly more vulnerable image.

devcontainer.json pulls the image by default. New users get the tools in a pull instead of a half-hour build. CONTRIBUTING.md covers switching to the local build when changing the image itself.

Actions are pinned to commit SHAs with the version in a trailing comment.

Verified locally

shellcheck -x -S warning, bash -n, JSON parse over every file, hadolint --config .hadolint.yaml, and actionlint all pass. devcontainer.json still parses as JSONC. No broken relative links in any markdown file.

Not verified: the build itself has not run. The likely first failures are disk exhaustion (runners have ~14 GB free, hence the reclaim step) and an arm64 gap in a tool I could only check by release-asset name.

Incidental fixes

Found while working through the files, all in the docs commit:

  • README's licence section said [Add your license information here] above a real MIT LICENSE; Support was a placeholder too
  • git clone <repository-url> in README and QUICKSTART
  • VERSION_MANAGEMENT.md told you to edit .devcontainer/build/devcontainer.json or .devcontainer/local/devcontainer.json; neither exists
  • QUICKSTART linked to .devcontainer/variants/README.md, which does not exist
  • .pre-commit/README.md claimed pre-commit ran in the ADO pipeline; it did not
  • Two [Unreleased] CHANGELOG entries contradicted the changes in this PR
  • ARCHITECTURE.md opened a code fence at line 3 and never closed it
  • Helm's checksum was downloaded and then verified by a commented-out line

Worth a look before merge

  • .secrets.baseline is missing, so pre-commit run --all-files fails on the detect-secrets hook. CI runs the linters directly rather than through pre-commit, so this does not block, but the hook is broken for contributors. Left alone as out of scope.
  • install-azcopy.sh's pinned-version path is broken independently of this change: the URL contains a shell glob that curl cannot expand, and azcopyvnext.azureedge.net is a retired CDN. Only the "latest" path is exercised today, so nothing fails. Arch handling was fixed on both paths.
  • ARCHITECTURE.md still lists "Azure ACR (Images)" and "DevOps (CI/CD)" in its external-connections diagram. Left in place because it reads as Azure services the container talks to, which is fair for a DevOps toolbox, but you may want it gone now.
  • Scheduled workflows are disabled after 60 days without repo activity. If releases stop, check that before hunting a bug. Noted in release.yml.

Follow-up: the deferred items are now resolved

The "worth a look before merge" list above has been dealt with, plus a few things found on the way.

pre-commit run --all-files now exits 0. It previously failed before reaching half its hooks, for four separate reasons:

  • .secrets.baseline did not exist, so detect-secrets aborted the run
  • check-json was parsing devcontainer.json, which is JSONC
  • 21 tracked scripts carried a shebang without an executable bit. The Dockerfile chmods them at build, which is why it never showed up. (My earlier note said this affected one file - I had only read the head of the hook's output.)
  • the ansible-lint hook pinned v6.22.1, which no longer imports against current ansible-core, and its files: \.(yaml|yml)$ pattern aimed it at the GitHub workflow files. This repo has no playbooks or roles, so the hook and .ansible-lint are removed rather than repaired.

markdownlint and yamllint were failing on their defaults against content that had never satisfied them - MD013 at 80 columns, a --- document start that GitHub workflows do not use, and the perennial complaint that the on: trigger key is a YAML 1.1 boolean. Both now have config files, matching how .hadolint.yaml already works. The findings that were real are fixed at source rather than configured away: code fences given languages, a literal <tool> backticked, two over-long lines in build.yml shortened.

install-azcopy.sh's pinned path is fixed. It pointed at a retired CDN through a URL containing a shell glob curl cannot expand. It now uses the GitHub release asset, verified 200 on both architectures at v10.32.6.

Line endings are declared. The repo mixed CRLF root docs with LF scripts and declared neither, so mixed-line-ending kept rewriting whole files. .gitattributes now sets LF throughout, which is what devcontainer.json already configured VS Code for. That commit changes no content - a whitespace-blind diff over it is empty apart from .gitattributes itself.

The ACR/DevOps diagram entries are gone, replaced with Entra ID and a line saying plainly that the box is what the tooling talks to, not where the image comes from.

One thing I got wrong

My earlier ### Removed entry landed partway down the original ### Added list, so the changelog read "Removed: Installation scripts ... Terraform, Terragrunt, Azure CLI" - the opposite of what happened. [Unreleased] also had two ### Added and two ### Changed blocks. Each subsection now appears once, original entries preserved.

Build status

The previous CI run went green end to end: lint, build (amd64) and build (arm64) all passed, with merge and attest correctly skipped on a pull request. So the arm64 script rewrite, the PowerShell tarball path and the disk headroom all hold up in practice - those were the three unknowns flagged above.

hadolint-docker is the one hook I could not run here; it needs a Docker daemon, which is a prerequisite of this repo anyway. CI runs hadolint directly against the same .hadolint.yaml.

grinidx added 7 commits August 3, 2026 19:13
Ten install scripts hardcoded amd64/x86_64/x64, which made an arm64 build
impossible. Add a sourced _arch.sh helper exporting the three spellings
upstreams actually use, and switch every affected script to it.

All ten tools publish arm64 Linux artefacts. PowerShell is the exception:
Microsoft's Ubuntu package repository has no arm64 'powershell' deb, so
arm64 installs from the upstream tarball into /opt/microsoft/powershell/7,
resolving the runtime dependencies the deb would have pulled in.

Also verify Helm's checksum, which was downloaded and then checked by a
commented-out line, and drop an unused variable that shellcheck flags.
Replaces azure-pipelines.yml, which needed an Azure DevOps agent pool, an
ACR service connection and a Dependency-Track instance to run at all - none
of which a public repository can offer a contributor.

ci.yml runs on pull requests and pushes to main; release.yml cuts a weekly
calendar-versioned release from a --no-cache rebuild. Both call the reusable
build.yml, so the two paths cannot drift.

Each architecture builds on its own native runner, loads the image, runs
tests/run-all-tests.sh inside it, and only then pushes by digest; a merge
job assembles the manifest list. Docker's own github-builder workflow was
the shorter route but supports neither local loading nor per-platform tests.

Published images carry a BuildKit SBOM and provenance plus a Sigstore-signed
SLSA attestation from actions/attest, which is why there is no separate
cosign step. Trivy reports to the Security tab without gating: this image
always carries some upstream HIGHs, and blocking on them would stall the
weekly rebuild and leave users on a strictly more vulnerable image.

Auth is the repository's own GITHUB_TOKEN, so there are no secrets to set
up. Actions are pinned to commit SHAs.

Dockerfile lint rules move to .hadolint.yaml with a reason against each, so
CI and the pre-commit hook agree by construction.
devcontainer.json now points at ghcr.io/grinidx/devcontainer-devops:latest,
with the local Dockerfile build as the commented-out contributor path. New
users get the tools in a pull rather than a half-hour build; CONTRIBUTING
covers switching back when changing the image itself.

Document CalVer honestly: most of versions.json installs latest, so a tag is
a point-in-time snapshot and the digest is the only stable identifier.

ARCHITECTURE gains a CI/CD section covering why the matrix is hand-rolled,
why the runners are native rather than QEMU, and why scans do not gate. Its
code fence was also never closed, so everything after line 3 was rendering
as one block.

Fixes several stale or placeholder references found on the way through:
- README's licence section said '[Add your license information here]' over a
  real MIT LICENSE file, and Support was a placeholder
- 'git clone <repository-url>' in README and QUICKSTART
- VERSION_MANAGEMENT pointed at .devcontainer/build/ and .devcontainer/local/,
  neither of which exists
- QUICKSTART linked to .devcontainer/variants/README.md, which does not exist
- .pre-commit/README claimed pre-commit ran in the Azure DevOps pipeline; it
  did not, and now there is no such pipeline
- two Unreleased CHANGELOG entries contradicted the changes above
The repo held a mix with nothing declaring which was intended: the root
docs and configs were CRLF, every shell script under .devcontainer/files/
was LF. Editing a CRLF file with an LF-writing tool left it mixed, and the
mixed-line-ending pre-commit hook then rewrote the whole file.

Settle on LF, which is what VS Code was already configured for here
(devcontainer.json sets files.eol to \n) and what three quarters of the
tracked files already used. Shell scripts especially need it - a CRLF
shebang makes the kernel look for an interpreter with a trailing carriage
return and fails with a confusing 'bad interpreter'.

Apart from .gitattributes itself this commit changes no content; a
whitespace-blind diff over it is empty.
The pinned branch could never have worked. It pointed at
azcopyvnext.azureedge.net, a retired CDN, through a path containing a shell
glob ('release-${VERSION}-20*') that curl has no way to expand. Nothing
noticed because versions.json leaves azcopy empty, so only the 'latest'
branch is ever taken.

Use the GitHub release asset instead, which is where the aka.ms aliases
redirect to anyway. Verified that both architectures resolve 200 at
v10.32.6.
'pre-commit run --all-files' failed before reaching half its hooks. Four
separate causes:

- .secrets.baseline did not exist, so the detect-secrets hook aborted
- check-json parsed .devcontainer/devcontainer.json, which is JSONC and
  legitimately contains comments
- 21 tracked scripts carried a shebang without an executable bit. The
  Dockerfile chmods them at build time, which is why nobody noticed
- the ansible-lint hook pinned v6.22.1, incompatible with current
  ansible-core, and its 'files: \.(yaml|yml)$' pattern aimed it at the
  GitHub workflow files. This repo has no playbooks or roles, so the hook
  and .ansible-lint are removed rather than repaired

markdownlint and yamllint were failing on their defaults against content
that had never satisfied them - MD013 at 80 columns, a document-start
marker GitHub workflows do not use, and the perennial yamllint complaint
that the 'on:' trigger key is a YAML 1.1 boolean. Both now have config
files, matching how .hadolint.yaml already works, and the findings that
were real are fixed at source: code fences given languages, a literal
<tool> backticked, and two over-long lines in build.yml shortened.

Those two lines shrank by collapsing the repeated
'${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}' pair, which appeared eight
times, into a single env.IMAGE.

Verified: pre-commit run --all-files now exits 0, with only hadolint-docker
skipped for want of a Docker daemon in this environment.
The [Unreleased] section had two '### Added' and two '### Changed' blocks,
and my earlier commit inserted '### Removed' partway down the first Added
list. The result read 'Removed: Installation scripts ... Terraform,
Terragrunt, Azure CLI', which is the opposite of what happened. Each
subsection now appears once, in Keep a Changelog order, with the original
entries preserved and this branch's entries appended.

ARCHITECTURE's external-connections diagram listed 'Azure ACR (Images)' and
'DevOps (CI/CD)' directly above the build flow, reading as though the image
were still built in Azure DevOps and stored in ACR. The box describes what
the tooling inside the container talks to, so it keeps ACR as a registry
the user works with, swaps the stale DevOps entry for Entra ID, which is
what az and kubelogin actually authenticate against, and says plainly that
this is not where the image comes from.
@grinidx
grinidx merged commit e6fd0f3 into main Aug 4, 2026
4 checks passed
@grinidx
grinidx deleted the ci/github-actions-ghcr branch August 4, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant