diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbc957c7..9d016ae4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: gates: runs-on: ubuntu-latest @@ -56,3 +59,12 @@ jobs: - name: Production quality gate working-directory: ./frontend run: npm run check:quality + + release: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [gates, controller, frontend] + permissions: + contents: write + issues: write + pull-requests: write + uses: ./.github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ccdce00..6f34cee8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,39 +1,47 @@ name: Release on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] + workflow_call: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + queue: max permissions: - contents: write - issues: write - pull-requests: write + contents: read jobs: release: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha }} - - - name: Select tested main revision - run: git checkout -B main "${{ github.event.workflow_run.head_sha }}" + ref: ${{ github.sha }} - uses: actions/setup-node@v4 with: node-version: 22.19.0 + - name: Verify tested main revision + id: revision + env: + TESTED_SHA: ${{ github.sha }} + run: node scripts/release-revision.mjs + - name: Configure git author + if: steps.revision.outputs.current == 'true' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Release + if: steps.revision.outputs.current == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/frontend/package.json b/frontend/package.json index 8f191762..c6009aac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "check:deadcode": "knip", "check:dupes": "jscpd src", "check:cleanup": "npm run check:deadcode && npm run check:dupes && npm run depcheck", + "check:release-workflow": "node --test scripts/release-workflow.test.mjs", "check:ui-structure": "node scripts/validate-ui-structure.mjs", "check:fix": "knip --fix", "desktop:build:main": "tsc -p desktop/tsconfig.json", @@ -38,7 +39,7 @@ "typecheck": "tsc --noEmit", "typecheck:desktop": "tsc -p desktop/tsconfig.json", "check:cycles": "madge --extensions ts,tsx --circular src", - "check:static": "npm run lint && npm run typecheck && npm run typecheck:desktop && npm run check:cycles && npm run check:ui-structure", + "check:static": "npm run lint && npm run typecheck && npm run typecheck:desktop && npm run check:cycles && npm run check:release-workflow && npm run check:ui-structure", "check:quality": "node scripts/validate-package-json.mjs && npm run check:static && npm run check:cleanup && npm run build", "precommit": "lint-staged --config .lintstagedrc.json && npm run typecheck" }, diff --git a/frontend/scripts/release-workflow.test.mjs b/frontend/scripts/release-workflow.test.mjs new file mode 100644 index 00000000..47c89860 --- /dev/null +++ b/frontend/scripts/release-workflow.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { resolve } from "node:path"; +import { parse } from "yaml"; +import { releaseRevisionIsCurrent } from "../../scripts/release-revision.mjs"; + +const repository = resolve(import.meta.dirname, "../.."); + +function workflow(name) { + return parse(readFileSync(resolve(repository, ".github/workflows", name), "utf8")); +} + +function concurrencyOrder(running, pending, queue) { + return [running, ...(queue === "max" ? pending : pending.slice(-1))]; +} + +test("rejects an older release that enters after a newer revision", () => { + const older = "a".repeat(40); + const newer = "b".repeat(40); + const acquired = [ + [newer, newer, newer], + [older, older, newer], + ]; + assert.deepEqual( + acquired.filter((entry) => releaseRevisionIsCurrent(...entry)).map(([tested]) => tested), + [newer], + ); + assert.deepEqual( + [ + [older, older, older], + [newer, newer, newer], + ] + .filter((entry) => releaseRevisionIsCurrent(...entry)) + .map(([tested]) => tested), + [older, newer], + ); +}); + +test("preserves a pending current release when a stale run enters third", () => { + const release = workflow("release.yml"); + const running = "c".repeat(40); + const current = "b".repeat(40); + const stale = "a".repeat(40); + const acquired = concurrencyOrder(running, [current, stale], release.concurrency.queue); + assert.deepEqual(acquired, [running, current, stale]); + assert.deepEqual( + acquired + .slice(1) + .filter((tested) => releaseRevisionIsCurrent(tested, tested, current)), + [current], + ); +}); + +test("binds release publication and write permissions to the tested revision", () => { + const ci = workflow("ci.yml"); + const release = workflow("release.yml"); + const checkout = release.jobs.release.steps.find((step) => step.uses === "actions/checkout@v4"); + const revision = release.jobs.release.steps.find((step) => step.id === "revision"); + const publication = release.jobs.release.steps.find((step) => + String(step.run ?? "").includes("semantic-release"), + ); + assert.deepEqual(ci.permissions, { contents: "read" }); + assert.deepEqual(ci.jobs.release.needs, ["gates", "controller", "frontend"]); + assert.deepEqual(ci.jobs.release.permissions, { + contents: "write", + issues: "write", + "pull-requests": "write", + }); + assert.deepEqual(release.permissions, { contents: "read" }); + assert.deepEqual(release.concurrency, { + group: "release-${{ github.ref }}", + "cancel-in-progress": false, + queue: "max", + }); + assert.deepEqual(release.jobs.release.permissions, { + contents: "write", + issues: "write", + "pull-requests": "write", + }); + assert.equal(checkout.with.ref, "${{ github.sha }}"); + assert.equal(revision.env.TESTED_SHA, "${{ github.sha }}"); + assert.equal(revision.run, "node scripts/release-revision.mjs"); + assert.ok(release.jobs.release.steps.indexOf(revision) < release.jobs.release.steps.indexOf(publication)); + assert.equal(publication.if, "steps.revision.outputs.current == 'true'"); +}); diff --git a/scripts/release-revision.mjs b/scripts/release-revision.mjs new file mode 100644 index 00000000..4e8f5a4b --- /dev/null +++ b/scripts/release-revision.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const revisionPattern = /^[0-9a-f]{40}$/; + +function verifiedRevision(revision) { + const value = String(revision ?? "").trim(); + if (!revisionPattern.test(value)) throw new Error("Release revision is invalid"); + return value; +} + +export function releaseRevisionIsCurrent(tested, checkedOut, currentMain) { + const expected = verifiedRevision(tested); + return expected === verifiedRevision(checkedOut) && expected === verifiedRevision(currentMain); +} + +function gitOutput(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function currentMainRevision() { + execFileSync( + "git", + ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"], + { stdio: "inherit" }, + ); + return gitOutput(["rev-parse", "refs/remotes/origin/main"]); +} + +function publishRevisionState(environment = process.env) { + const output = environment.GITHUB_OUTPUT?.trim(); + if (!output) throw new Error("Release output destination is unavailable"); + const tested = verifiedRevision(environment.TESTED_SHA); + const checkedOut = gitOutput(["rev-parse", "HEAD"]); + const currentMain = currentMainRevision(); + const current = releaseRevisionIsCurrent(tested, checkedOut, currentMain); + appendFileSync(output, `current=${current}\n`); + console.log( + current + ? `Release revision ${tested} is current` + : `Skipping stale release revision ${tested}; current main is ${currentMain}`, + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + publishRevisionState(); +}