Skip to content
Draft
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
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
name: CI

# Production pushes already run the same build/smoke tests in pages-stage.
# Run CI for pull requests only; superseded commits never need finishing.
on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: cinema-ci-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
build:
name: Build Astro preview
Expand Down Expand Up @@ -36,6 +39,7 @@ jobs:
node scripts/seat-color-test.mjs
node scripts/seo-audit.mjs
node scripts/check-outbound-links.mjs
node scripts/check-actions-dedup.mjs
- name: Verify explicitly indexable build
run: |
SEO_INDEXABLE=true npm run build
Expand Down
66 changes: 61 additions & 5 deletions .github/workflows/cinema-catalog-daily.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ on:
schedule:
- cron: '10 18 * * *'
workflow_dispatch:
inputs:
force:
description: 'Re-fetch even if the catalog was already updated today'
type: boolean
default: false
# Bootstrap the first catalog immediately when this new workflow is merged.
# Future regular source pushes do NOT start a fetch.
push:
Expand Down Expand Up @@ -37,11 +42,42 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Skip a second catalog refresh on the same Vietnam date
id: due
shell: bash
env:
FORCE_REFRESH: ${{ inputs.force || false }}
run: |
set -euo pipefail
today="$(TZ=Asia/Ho_Chi_Minh date +%F)"
snapshot_day="$(node --input-type=module -e '
import { readFileSync } from "node:fs";
const stamp=JSON.parse(readFileSync("src/data/cinema-snapshot.json","utf8")).fetchedAt;
if (stamp && !Number.isNaN(Date.parse(stamp))) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat("en-GB", {
timeZone:"Asia/Ho_Chi_Minh", year:"numeric", month:"2-digit", day:"2-digit"
}).formatToParts(new Date(stamp)).map(item => [item.type, item.value])
);
console.log(parts.year + "-" + parts.month + "-" + parts.day);
}
')"
if [[ "$FORCE_REFRESH" != "true" && "$snapshot_day" == "$today" ]]; then
echo "due=false" >> "$GITHUB_OUTPUT"
echo "Catalog already refreshed today ($today); skip duplicate fetch/build/deploy." >> "$GITHUB_STEP_SUMMARY"
else
echo "due=true" >> "$GITHUB_OUTPUT"
echo "Catalog refresh due (today=$today, cached=${snapshot_day:-none})."
fi

- name: Install dependencies
if: steps.due.outputs.due == 'true'
run: npm install --no-audit --no-fund
- name: Download film and cinema directory only
if: steps.due.outputs.due == 'true'
run: node scripts/sync-cinema-catalog.mjs
- name: Validate new snapshot and prerendered pages
if: steps.due.outputs.due == 'true'
run: |
node scripts/sync-cinema-catalog.mjs --check
npm run build
Expand All @@ -50,6 +86,7 @@ jobs:
node scripts/seo-audit.mjs
node scripts/check-outbound-links.mjs
- name: Commit new catalog to main for normal Pages deployment
if: steps.due.outputs.due == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -63,9 +100,28 @@ jobs:
exit 0
fi
git commit -m "data(cinema): refresh daily movie and theater snapshot"
# Never force push, rewrite history, or hide branch protections.
git push origin HEAD:main
# GITHUB_TOKEN pushes do NOT trigger regular on:push workflows.
# workflow_dispatch is specifically allowed, so explicitly build/deploy.
# Avoid the non-fast-forward failure observed on the first bootstrap:
# rebase only when main advanced, without force-pushing or overwriting
# another catalog update. A conflict fails closed.
pushed=false
for attempt in 1 2 3; do
git fetch origin main
if ! git rebase origin/main; then
git rebase --abort || true
echo "Main now has conflicting catalog updates; refusing to overwrite." >&2
exit 1
fi
if git push origin HEAD:main; then
pushed=true
break
fi
echo "Main advanced during push attempt $attempt; retrying safely."
done
if [[ "$pushed" != "true" ]]; then
echo "Unable to fast-forward main after 3 attempts." >&2
exit 1
fi
# GITHUB_TOKEN pushes do not trigger standard on:push workflows.
# Dispatch the normal stage exactly once for this successful commit.
gh workflow run pages-stage.yml --ref main
echo "Snapshot committed and Pages deployment explicitly dispatched."
echo "Snapshot committed; one Pages staging run dispatched."
9 changes: 4 additions & 5 deletions .github/workflows/pages-publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
name: Publish GitHub Pages

# Only the staging workflow dispatches this after it PUSHES a changed
# gh-pages tree. A successful no-op stage must not create another deploy.
# This trusted workflow always runs from the default branch.
# PR workflows only stage static files; they cannot deploy Pages directly.
on:
workflow_run:
workflows: ["Build and stage GitHub Pages"]
types: [completed]
workflow_dispatch:

permissions:
contents: read
Expand All @@ -18,7 +17,7 @@ concurrency:

jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
Expand Down
66 changes: 61 additions & 5 deletions .github/workflows/pages-stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ on:

permissions:
contents: write
pull-requests: read
actions: write

# One writer at a time to keep production and all PR previews intact.
# Serialize gh-pages writes; never interrupt a publishing write.
# Queue all distinct PR-close and main events, then skip stale/duplicate runs
# cheaply before installing dependencies.
concurrency:
group: cinema-pages-stage
cancel-in-progress: false
queue: max

jobs:
stage:
Expand Down Expand Up @@ -70,32 +75,83 @@ jobs:
echo "preview_url=${PAGES_SITE_ORIGIN}${BASE_PATH}" >> "$GITHUB_OUTPUT"
echo "Deploy kind: $KIND | Base path: $BASE_PATH"

- name: Skip superseded and already-staged events
id: gate
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
PR_ACTION: ${{ github.event.action || '' }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }}
EXPECTED_SHA: ${{ github.sha }}
run: |
set -euo pipefail
should_run=true
reason="new content"
read_marker() {
gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=gh-pages" --jq '.content' 2>/dev/null | base64 --decode 2>/dev/null || true
}
if [[ "$EVENT_NAME" == "pull_request" ]]; then
current_pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
state="$(jq -r '.state' <<< "$current_pr")"
actual_sha="$(jq -r '.head.sha' <<< "$current_pr")"
if [[ "$PR_ACTION" == "closed" ]]; then
if [[ "$state" == "open" ]]; then
should_run=false
reason="PR reopened after close event"
fi
elif [[ "$state" != "open" || "$actual_sha" != "$PR_HEAD_SHA" ]]; then
should_run=false
reason="PR closed or replaced by a newer commit"
elif [[ "$(read_marker "previews/pr-${PR_NUMBER}/.eplus-preview-sha")" == "$PR_HEAD_SHA" ]]; then
should_run=false
reason="same PR commit already staged"
fi
else
actual_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')"
if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then
should_run=false
reason="superseded main commit"
elif [[ "$EVENT_NAME" == "push" && "$(read_marker '.eplus-production-sha')" == "$EXPECTED_SHA" ]]; then
should_run=false
reason="same main commit already staged"
fi
fi
echo "run=$should_run" >> "$GITHUB_OUTPUT"
echo "Stage gate: $should_run ($reason)"
echo "Stage gate: $should_run ($reason)" >> "$GITHUB_STEP_SUMMARY"

- name: Setup Node
if: github.event.action != 'closed'
if: steps.gate.outputs.run == 'true' && github.event.action != 'closed'
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Install dependencies
if: github.event.action != 'closed'
if: steps.gate.outputs.run == 'true' && github.event.action != 'closed'
run: npm install --no-audit --no-fund

- name: Build Astro site
if: github.event.action != 'closed'
if: steps.gate.outputs.run == 'true' && github.event.action != 'closed'
run: |
npm run build
node scripts/rewrite-links.mjs
node scripts/smoke-test.mjs
node scripts/seat-color-test.mjs
node scripts/seo-audit.mjs
node scripts/check-outbound-links.mjs
node scripts/check-actions-dedup.mjs

- name: Stage full site and preview into gh-pages
if: steps.gate.outputs.run == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }}
run: bash scripts/publish-pages.sh

- name: Pages publishing summary
if: github.event_name == 'pull_request'
if: steps.gate.outputs.run == 'true' && github.event_name == 'pull_request'
env:
PREVIEW_URL: ${{ steps.config.outputs.preview_url }}
PR_ACTION: ${{ github.event.action }}
Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ sections inside the empty `<main>` before the footer and normalizes preview
links. This runs automatically after `npm run build`. The smoke tests
verify that all primary pages have valid main/footer order.

### GitHub Actions deduplication

- **PR CI** runs only on pull requests. New commits cancel obsolete PR
checks through a per-PR concurrency group. Production `main` builds and
smoke checks are performed by Pages Staging, not duplicate CI jobs.
- **Pages Staging** serializes writes to the shared `gh-pages` branch.
Separate PR-close/production events remain queued, but stale PR HEADs,
superseded `main` SHA values and already-staged commits are detected
before dependency installation. Each staged preview carries an
`.eplus-preview-sha` marker; production has `.eplus-production-sha`.
HEAD is verified again immediately before publishing.
- **Pages Publishing** is dispatched only after a successful push that
actually changes `gh-pages`. A stage with no changes **does not**
dispatch another Pages deployment. The deployment workflow runs from
`main` with `workflow_dispatch`, not `workflow_run: completed`.
- **Catalog refresh** still has one daily schedule at 01:10 Vietnam time.
If the snapshot was already fetched on that Vietnam calendar date, the
job skips loading and deployment. A manual workflow run with
`force=true` explicitly overrides the same-day check. Bot commits fetch
and rebase against the latest `main` before attempting a non-force push;
conflicts fail closed, preserving existing data.
- GitHub Actions concurrency itself only prevents overlapping executions;
preserving independent preview deletions is why staging uses
`queue: max` with per-event freshness checks, not a blanket
`cancel-in-progress: true`. See `scripts/check-actions-dedup.mjs` for
CI-validated safety invariants.

## GitHub Pages deployment

Two workflows work together:
Expand All @@ -112,17 +139,17 @@ Two workflows work together:
- `workflow_dispatch`: manually rebuild production from `main`.
- Builds run with `persist-credentials: false`; the publish step receives the scoped `GITHUB_TOKEN` separately.
2. **Publish GitHub Pages** (`.github/workflows/pages-publish.yml`):
- Runs from the repository's **default branch** after the staging workflow succeeds.
- Runs from the repository's **default branch** only when staging pushes a **real change** to `gh-pages` and dispatches the publisher.
- Checks out the complete `gh-pages` branch, uploads one Pages artifact and deploys it with GitHub's official `actions/deploy-pages`.
- Does not execute PR source code and avoids deploying directly from a PR context.

This staging/deployment split is necessary because public GitHub Pages does not offer native isolated PR preview deployments, and commits made by `GITHUB_TOKEN` alone do **not** reliably trigger a Pages publish. Our preview folders are part of the same complete GitHub Pages site.
This staging/deployment split is necessary because public GitHub Pages does not offer native isolated PR preview deployments. Commits made by `GITHUB_TOKEN` do not trigger normal push workflows, so staging explicitly sends one `workflow_dispatch` after each changed `gh-pages` commit. A no-op staging run sends none. Preview folders are part of the same complete Pages site.

### One-time GitHub setup (required)

1. Merge the deployment PR so both workflow files are on `main`.
2. Visit **Repository → Settings → Pages** and set **Build and deployment → Source → GitHub Actions**. Do **not** select `Deploy from a branch` for this configuration.
3. In **Settings → Actions → General**, ensure workflows can use the repository-scoped `GITHUB_TOKEN` with the requested `contents: write` permission. If your organization restricts writable workflow tokens, an administrator must allow the necessary policy.
3. In **Settings → Actions → General**, ensure workflows can use the scoped `GITHUB_TOKEN` with `contents: write` and `actions: write` as requested. If the organization restricts writable workflow tokens, an administrator must adjust the repository policy.
4. Under **Settings → Environments → github-pages**, allow default-branch deployments. The trusted publishing workflow runs from `main`.
5. Trigger **Actions → Build and stage GitHub Pages → Run workflow** on `main` for the first production release (or use the automatic `main` push).
6. Check **Actions → Publish GitHub Pages** for successful deployment.
Expand Down Expand Up @@ -179,7 +206,7 @@ scripts/
.github/workflows/
ci.yml # Pull-request static build
pages-stage.yml # Build and stage production / previews
pages-publish.yml # Official GitHub Pages deployment
pages-publish.yml # Dispatch-only GitHub Pages deployment
```

## Real showtime data from MoMo (experimental)
Expand Down
39 changes: 39 additions & 0 deletions scripts/check-actions-dedup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const ci = readFileSync('.github/workflows/ci.yml', 'utf8');
const stage = readFileSync('.github/workflows/pages-stage.yml', 'utf8');
const publish = readFileSync('.github/workflows/pages-publish.yml', 'utf8');
const daily = readFileSync('.github/workflows/cinema-catalog-daily.yml', 'utf8');
const stagingScript = readFileSync('scripts/publish-pages.sh', 'utf8');

// CI is for PRs. The staging workflow validates main after pushes.
assert.match(ci, /on:\s*\n\s*pull_request:/, 'PR CI trigger is missing');
assert.doesNotMatch(ci.split('permissions:')[0], /^\s*push:/m, 'Main push must not duplicate CI builds');
assert.match(ci, /cancel-in-progress: true/, 'Superseded PR CI must be cancelled');

assert.match(stage, /group: cinema-pages-stage/, 'Pages staging needs a shared writer lock');
assert.match(stage, /cancel-in-progress: false/, 'Do not interrupt a gh-pages write');
assert.match(stage, /queue: max/, 'Distinct PR closures must not be lost from the queue');
assert.match(stage, /id: gate/, 'Stage event/commit dedup gate missing');
assert.match(stage, /read_marker/, 'Stage should compare previously staged commit');
assert.match(stage, /steps\.gate\.outputs\.run == 'true'/, 'Build must respect the gate');
assert.match(stage, /actions: write/, 'Publisher dispatch permission missing');

assert.match(publish, /^\s+workflow_dispatch:/m, 'Publisher needs dispatch-only trigger');
assert.doesNotMatch(publish, /workflow_run:/, 'Publisher must not run on no-op staging success');
assert.match(publish, /group: cinema-pages-publish/, 'Publisher needs serial deployment');

assert.match(stagingScript, /\.eplus-production-sha/, 'Production marker not saved');
assert.match(stagingScript, /\.eplus-preview-sha/, 'PR preview marker not saved');
assert.match(stagingScript, /No gh-pages changes; no Pages publish dispatched/, 'No-op stage must not dispatch publish');
assert.match(stagingScript, /gh workflow run pages-publish\.yml --ref main/, 'Changed stage must dispatch publish');

assert.match(daily, /group: cinema-catalog-daily/, 'Catalog refresh concurrency missing');
assert.match(daily, /steps\.due\.outputs\.due == 'true'/, 'Same-day refresh guard missing');
assert.match(daily, /git rebase origin\/main/, 'Concurrent main update recovery missing');
assert.match(daily, /gh workflow run pages-stage\.yml --ref main/, 'GITHUB_TOKEN commit needs explicit stage dispatch');
assert.equal((daily.match(/cron:/g) || []).length, 1, 'Catalog must have one scheduled cron');

console.log('PASS: CI, staging, publishing and catalog dedup invariants');
Loading
Loading