diff --git a/.github/release-tools/.gitignore b/.github/release-tools/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/.github/release-tools/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.github/release-tools/README.md b/.github/release-tools/README.md index 17946ff637..0f8ad75b2c 100644 --- a/.github/release-tools/README.md +++ b/.github/release-tools/README.md @@ -4,9 +4,9 @@ The release workflow builds the frontend once, then runs each configured Go targ Each build uses GoReleaser OSS in snapshot mode with the selected release version and one target. Archive naming, bundled files, Go flags and release templates remain in the existing GoReleaser configurations. Every archive is accompanied by its source commit, target, version and SHA256. The publishing job verifies the complete matrix before building images or publishing. It uses GoReleaser's `extra_files` support to publish existing archives and checksums, with builds disabled. No Pro license is needed. -Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description. +Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Save uses the original restore key, even if a build hook changes `go.sum`. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description. -All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. The workflow serializes release runs to prevent simultaneous updates to moving image tags. +All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. Helper scripts come from the workflow revision and are passed as a run-local artifact, so older application tags do not need to contain the new scripts. The workflow serializes release runs to prevent simultaneous updates to moving image tags. ## Validate without publication diff --git a/.github/release-tools/test_release_matrix.py b/.github/release-tools/test_release_matrix.py index b1a215518d..bbfc0fc673 100644 --- a/.github/release-tools/test_release_matrix.py +++ b/.github/release-tools/test_release_matrix.py @@ -159,5 +159,33 @@ class ReleaseMatrixTest(unittest.TestCase): self.assertIn('ghcr.io/exampleowner/sub2api', log) + def test_published_full_and_simple_image_tags(self): + fake_bin = Path('bin') + fake_bin.mkdir() + docker = fake_bin / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') + docker.chmod(0o755) + for simple in (False, True): + with self.subTest(simple=simple): + log_path = Path(f'docker-{simple}.log').resolve() + env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], + 'DOCKER_LOG': str(log_path), 'RUNNER_TEMP': self.temp.name, + 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', + 'DRY_RUN': 'false', 'SIMPLE_RELEASE': str(simple).lower(), 'DOCKERHUB_USERNAME': 'fixturehub'} + subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) + log = log_path.read_text() + self.assertIn('--push', log) + self.assertEqual(log.count('buildx build'), 1 if simple else 2) + if simple: + self.assertNotIn('fixturehub', log) + self.assertNotIn('imagetools', log) + self.assertIn('ghcr.io/exampleowner/sub2api:latest', log) + else: + self.assertEqual(log.count('imagetools create'), 2) + self.assertIn('fixturehub/sub2api:9.8', log) + self.assertIn('ghcr.io/exampleowner/sub2api:9', log) + + + if __name__ == '__main__': unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71f4f5d253..f14db67661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,14 +45,21 @@ jobs: simple: ${{ steps.plan.outputs.simple }} dry_run: ${{ steps.plan.outputs.dry_run }} steps: - - uses: actions/checkout@v6 + - name: Checkout workflow tooling + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + - name: Preserve tooling for releases of older tags + run: cp -R .github/release-tools "$RUNNER_TEMP/release-tools" + - name: Checkout selected application source + uses: actions/checkout@v6 with: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - uses: actions/setup-python@v6 with: python-version: '3.12' - - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Resolve one source commit and target matrix id: plan env: @@ -61,7 +68,12 @@ jobs: args=(--ref "$REQUESTED_REF") if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi - python .github/release-tools/release_matrix.py plan "${args[@]}" + python "$RUNNER_TEMP/release-tools/release_matrix.py" plan "${args[@]}" + - uses: actions/upload-artifact@v7 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ + retention-days: 1 - uses: actions/upload-artifact@v7 with: name: version-file @@ -107,6 +119,10 @@ jobs: with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 + - uses: actions/download-artifact@v8 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ - uses: actions/download-artifact@v8 with: name: version-file @@ -136,17 +152,17 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' - - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Select one GoReleaser target env: TARGET_OS: ${{ matrix.goos }} TARGET_ARCH: ${{ matrix.goarch }} - run: python .github/release-tools/release_matrix.py config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output .goreleaser.matrix.yaml + run: python "$RUNNER_TEMP/release-tools/release_matrix.py" config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output "$RUNNER_TEMP/goreleaser.matrix.yaml" - name: Build and archive without publishing uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' - args: release --snapshot --clean --skip=validate,publish,docker,announce --config=.goreleaser.matrix.yaml + args: release --snapshot --clean --skip=validate,publish,docker,announce --config=${{ runner.temp }}/goreleaser.matrix.yaml env: GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} @@ -158,7 +174,7 @@ jobs: TARGET_OS: ${{ matrix.goos }} TARGET_ARCH: ${{ matrix.goarch }} RELEASE_SHA: ${{ needs.prepare.outputs.sha }} - run: python .github/release-tools/release_matrix.py collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output + run: python "$RUNNER_TEMP/release-tools/release_matrix.py" collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output - uses: actions/upload-artifact@v7 with: name: release-binary-${{ matrix.goos }}-${{ matrix.goarch }} @@ -172,7 +188,7 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }} + key: ${{ steps.go-cache.outputs.cache-primary-key }} release: needs: [prepare, build-binaries] @@ -181,6 +197,8 @@ jobs: contents: write packages: write env: + SIMPLE_RELEASE: ${{ needs.prepare.outputs.simple }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_SHA: ${{ needs.prepare.outputs.sha }} @@ -189,6 +207,10 @@ jobs: with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 + - uses: actions/download-artifact@v8 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ - uses: actions/download-artifact@v8 with: pattern: release-binary-* @@ -197,12 +219,12 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' - - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Verify complete matrix and prepare Docker contexts run: | args=(--version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --input release-input --output .release-context) if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi - python .github/release-tools/release_matrix.py contexts "${args[@]}" + python "$RUNNER_TEMP/release-tools/release_matrix.py" contexts "${args[@]}" - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod @@ -229,7 +251,7 @@ jobs: - name: Build images and publish manifests env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} - run: bash .github/release-tools/release-images.sh + run: bash "$RUNNER_TEMP/release-tools/release-images.sh" - name: Get annotated tag notes id: tag_message run: | @@ -242,14 +264,14 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Prepare publication-only GoReleaser config run: | - args=(publish --output .goreleaser.publisher.yaml) + args=(publish --output "$RUNNER_TEMP/goreleaser.publisher.yaml") if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi - python .github/release-tools/release_matrix.py config "${args[@]}" + python "$RUNNER_TEMP/release-tools/release_matrix.py" config "${args[@]}" - name: Publish existing archives and release notes uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' - args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=.goreleaser.publisher.yaml + args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=${{ runner.temp }}/goreleaser.publisher.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }}