Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
347 changes: 343 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@ on:
push:
branches:
- main
- JJTGG-patch-1
- JJTGG-patch-2
pull_request:
branches:
- main
- JJTGG-patch-1
- JJTGG-patch-2
workflow_dispatch:

permissions:
contents: read

concurrency:
group: agore-ci-${{ github.workflow }}-${{ github.ref }}
group: agore-ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true

jobs:
checks:
name: Tests, typecheck, and production build
name: Tests, typecheck, build, and dependency audit
runs-on: ubuntu-latest
timeout-minutes: 15

Expand All @@ -33,8 +37,71 @@ jobs:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci
- name: Check whether manifest and lockfile are in sync
id: lockfile
run: |
node <<'NODE'
const fs = require("node:fs");

const manifest = JSON.parse(
fs.readFileSync("package.json", "utf8"),
);

const lock = JSON.parse(
fs.readFileSync("package-lock.json", "utf8"),
);

const lockRoot = lock.packages?.[""];

if (!lockRoot) {
console.error(
"package-lock.json has no root package entry.",
);
process.exit(2);
}

const sections = [
"dependencies",
"devDependencies",
"optionalDependencies",
];

const normalize = (value) =>
JSON.stringify(
Object.fromEntries(
Object.entries(value ?? {}).sort(
([left], [right]) =>
left.localeCompare(right),
),
),
);

const mismatches = sections.filter(
(section) =>
normalize(manifest[section]) !==
normalize(lockRoot[section]),
);

const needsRefresh = mismatches.length > 0;

console.log(
needsRefresh
? `Lockfile refresh required: ${mismatches.join(", ")}`
: "Manifest and lockfile root dependencies match.",
);

fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`needs-refresh=${needsRefresh}\n`,
);
NODE

- name: Refresh lockfile when required
if: steps.lockfile.outputs.needs-refresh == 'true'
run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund

- name: Install dependencies from lockfile
run: npm ci --no-audit --no-fund

- name: Run regression tests
run: npm test
Expand All @@ -48,3 +115,275 @@ jobs:
NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ci-placeholder-not-a-real-key
SUPABASE_SERVICE_ROLE_KEY: ci-placeholder-not-a-real-service-role-key

- name: Capture dependency audit report
if: always()
run: npm audit --json > npm-audit.json || true

- name: Upload dependency security results
if: always()
uses: actions/upload-artifact@v7
with:
name: agore-dependency-security-results
path: |
package-lock.json
npm-audit.json
if-no-files-found: warn
retention-days: 7

- name: Enforce high and critical vulnerability threshold
if: always()
run: |
node <<'NODE'
const fs = require("node:fs");

const reportPath = "npm-audit.json";

if (!fs.existsSync(reportPath)) {
console.error(
"Dependency audit report was not generated.",
);
process.exit(2);
}

let report;

try {
report = JSON.parse(
fs.readFileSync(reportPath, "utf8"),
);
} catch (error) {
console.error(
"Dependency audit report is invalid JSON.",
);
console.error(error);
process.exit(2);
}

if (report.error) {
console.error("npm audit could not complete:");
console.error(
JSON.stringify(report.error, null, 2),
);
process.exit(2);
}

const counts = report.metadata?.vulnerabilities;

if (!counts) {
console.error(
"Audit report has no vulnerability totals.",
);
process.exit(2);
}

const high = counts.high ?? 0;
const critical = counts.critical ?? 0;

console.log(
`Audit totals: critical=${critical}, ` +
`high=${high}, ` +
`moderate=${counts.moderate ?? 0}, ` +
`low=${counts.low ?? 0}`,
);

for (const [name, vulnerability] of Object.entries(
report.vulnerabilities ?? {},
)) {
if (
vulnerability.severity === "high" ||
vulnerability.severity === "critical"
) {
console.error(
`${name}: ${vulnerability.severity}; ` +
`range=${vulnerability.range}; ` +
`fixAvailable=${JSON.stringify(
vulnerability.fixAvailable ?? false,
)}`,
);
}
}

if (high > 0 || critical > 0) {
console.error(
"High or critical vulnerabilities remain.",
);
process.exit(1);
}

console.log(
"Dependency audit passed the high/critical threshold.",
);
NODE

sync-lockfile:
name: Synchronize dependency lockfile
runs-on: ubuntu-latest
needs: checks
if: github.event_name == 'push' && github.ref_name == 'JJTGG-patch-2' && needs.checks.result == 'success'
timeout-minutes: 10
permissions:
contents: write
actions: write

steps:
- name: Check out the triggering commit
uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 22

- name: Check whether manifest and lockfile are in sync
id: lockfile
run: |
node <<'NODE'
const fs = require("node:fs");

const manifest = JSON.parse(
fs.readFileSync("package.json", "utf8"),
);
const lock = JSON.parse(
fs.readFileSync("package-lock.json", "utf8"),
);
const lockRoot = lock.packages?.[""];

if (!lockRoot) {
console.error(
"package-lock.json has no root package entry.",
);
process.exit(2);
}

const sections = [
"dependencies",
"devDependencies",
"optionalDependencies",
];
const normalize = (value) =>
JSON.stringify(
Object.fromEntries(
Object.entries(value ?? {}).sort(
([left], [right]) =>
left.localeCompare(right),
),
),
);
const mismatches = sections.filter(
(section) =>
normalize(manifest[section]) !==
normalize(lockRoot[section]),
);
const needsRefresh = mismatches.length > 0;

console.log(
needsRefresh
? `Lockfile refresh required: ${mismatches.join(", ")}`
: "Manifest and lockfile root dependencies match.",
);
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`needs-refresh=${needsRefresh}\n`,
);
NODE

- name: Refresh lockfile without running package scripts
if: steps.lockfile.outputs.needs-refresh == 'true'
run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund

- name: Validate manifest and lockfile synchronization
run: |
node <<'NODE'
const fs = require("node:fs");

const manifest = JSON.parse(
fs.readFileSync("package.json", "utf8"),
);
const lock = JSON.parse(
fs.readFileSync("package-lock.json", "utf8"),
);
const lockRoot = lock.packages?.[""];

if (!lockRoot) {
console.error(
"package-lock.json has no root package entry.",
);
process.exit(2);
}

const sections = [
"dependencies",
"devDependencies",
"optionalDependencies",
];
const normalize = (value) =>
JSON.stringify(
Object.fromEntries(
Object.entries(value ?? {}).sort(
([left], [right]) =>
left.localeCompare(right),
),
),
);
const mismatches = sections.filter(
(section) =>
normalize(manifest[section]) !==
normalize(lockRoot[section]),
);

if (mismatches.length > 0) {
console.error(
`Manifest and lockfile still differ: ${mismatches.join(", ")}`,
);
process.exit(1);
}

console.log(
"Manifest and lockfile root dependency sections match.",
);
NODE

- name: Commit lockfile change when needed
id: commit-lockfile
run: |
set -euo pipefail

if git diff --quiet -- package-lock.json; then
echo "No package-lock.json change is required."
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi

git add -- package-lock.json
git diff --cached --check

git fetch --no-tags origin "refs/heads/${GITHUB_REF_NAME}"
remote_sha="$(git rev-parse FETCH_HEAD)"

if [[ "$remote_sha" != "$GITHUB_SHA" ]]; then
echo "Branch advanced while CI was running; refusing to commit a stale lockfile."
echo "The newer branch head is left untouched."
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi

git -c user.name="github-actions[bot]" \
-c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
commit -m "chore: sync package lockfile"

git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}"
echo "changed=true" >> "$GITHUB_OUTPUT"

- name: Dispatch verification run for the bot commit
if: steps.commit-lockfile.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api \
--method POST \
"repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/dispatches" \
-f "ref=${GITHUB_REF_NAME}"
Loading
Loading