Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
gates:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -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
32 changes: 20 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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: |
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
86 changes: 86 additions & 0 deletions frontend/scripts/release-workflow.test.mjs
Original file line number Diff line number Diff line change
@@ -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'");
});
50 changes: 50 additions & 0 deletions scripts/release-revision.mjs
Original file line number Diff line number Diff line change
@@ -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();
}