Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ version: 2
# updates, when enabled in the repository settings, do not follow this schedule.
updates:
- package-ecosystem: npm
target-branch: dev
directory: /
schedule:
interval: monthly
Expand Down Expand Up @@ -38,6 +39,7 @@ updates:
update-types:
- version-update:semver-major
- package-ecosystem: nuget
target-branch: dev
# Dependabot expands ProCoderJi.slnx here, covering the API and test projects
# together so shared .NET packages can move in one reviewed pull request.
directory: /backend
Expand All @@ -56,6 +58,7 @@ updates:
- minor
- patch
- package-ecosystem: github-actions
target-branch: dev
directory: /
schedule:
interval: monthly
Expand Down
24 changes: 24 additions & 0 deletions .github/scripts/branch-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { branchPolicy } from "./check-branch-policy.mjs";

const repository = "procoderji-com/procoderji";
const check = (base, head, sourceRepository = repository) => branchPolicy({ base, head, sourceRepository, repository });

test("contributors and bots can send changes to dev", () => {
assert.equal(check("dev", "feat/student-experience", "contributor/procoderji"), null);
assert.equal(check("dev", "dependabot/npm_and_yarn/example"), null);
});

test("main accepts only the canonical dev branch", () => {
assert.equal(check("main", "dev"), null);
assert.ok(check("main", "feat/student-experience"));
assert.ok(check("main", "dependabot/npm_and_yarn/example"));
assert.ok(check("main", "dev", "contributor/procoderji"));
});

test("unknown or absent targets do not silently pass", () => {
assert.ok(check("release/1.0", "dev"));
assert.ok(check(undefined, undefined));
assert.ok(branchPolicy({ base: "main", head: "dev" }));
});
26 changes: 26 additions & 0 deletions .github/scripts/check-branch-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { pathToFileURL } from "node:url";

export function branchPolicy({ base, head, sourceRepository, repository }) {
if (!base || !head || !sourceRepository || !repository) return "Pull-request branch metadata is incomplete.";
if (base === "dev") return null;
if (base === "main" && head === "dev" && sourceRepository === repository) return null;
if (base === "main") {
return "Production PRs must promote this repository's dev branch. Send feature and dependency PRs to dev first.";
}
return "Contributions target dev; production promotions target main.";
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const failure = branchPolicy({
base: process.env.PR_BASE,
head: process.env.PR_HEAD,
sourceRepository: process.env.PR_SOURCE_REPOSITORY,
repository: process.env.TARGET_REPOSITORY,
});
if (failure) {
console.error(failure);
process.exitCode = 1;
} else {
console.log("Branch policy passed.");
}
}
2 changes: 1 addition & 1 deletion .github/workflows/backend-containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:
- 'vercel.backend.json'
- '.github/workflows/backend-containers.yml'
push:
branches: [main]
branches: [dev, main]
paths:
- 'backend/**'
- 'shared/**'
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/branch-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Branch policy

on:
pull_request:
types: [opened, synchronize, reopened, edited, ready_for_review]

permissions:
contents: read

jobs:
branch-policy:
name: Branch policy
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out proposed policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Test branch policy
run: node --test .github/scripts/branch-policy.test.mjs

- name: Enforce contribution and promotion targets
env:
PR_BASE: ${{ github.event.pull_request.base.ref }}
PR_HEAD: ${{ github.event.pull_request.head.ref }}
PR_SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
TARGET_REPOSITORY: ${{ github.repository }}
run: node .github/scripts/check-branch-policy.mjs
45 changes: 43 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ name: CI
on:
pull_request:
push:
branches: [main]
branches: [dev, main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Cancel stale PR checks, but never interrupt a push run during a deployment.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
verify:
Expand Down Expand Up @@ -148,3 +149,43 @@ jobs:
ConnectionStrings__Platform: 'Host=localhost;Port=5432;Database=procoderji_test;Username=postgres;Password=postgres'
PublicOrigin: http://localhost:3000
run: dotnet run --project backend/src/ProCoderJi.Api --configuration Release --no-build --no-launch-profile -- --migrate

deploy:
name: Deploy checked branch
permissions:
contents: read
deployments: write
needs: [verify, site-quality, backend]
if: github.event_name == 'push' && github.repository == 'procoderji-com/procoderji' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
runs-on: ubuntu-24.04
timeout-minutes: 20
environment:
name: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
url: ${{ vars.PUBLIC_ORIGIN }}
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
steps:
- name: Check out checked commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: 24

- name: Install deployment CLI
run: npm install --global vercel@65.14.0

- name: Deploy backend, verify storage, then deploy frontend
env:
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
VERCEL_FRONTEND_PROJECT_ID: ${{ vars.VERCEL_FRONTEND_PROJECT_ID }}
VERCEL_BACKEND_PROJECT_ID: ${{ vars.VERCEL_BACKEND_PROJECT_ID }}
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
BACKEND_ORIGIN: ${{ vars.BACKEND_ORIGIN }}
VERCEL_FRONTEND_TOKEN: ${{ secrets.VERCEL_FRONTEND_TOKEN }}
VERCEL_BACKEND_TOKEN: ${{ secrets.VERCEL_BACKEND_TOKEN }}
run: node scripts/deploy-vercel.mjs
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ fork as `origin`, and continue from the same checkout.

## Start a branch

Update your local `main` before starting a new change:
Update your local `dev` before starting a new change:

```bash
git switch main
git fetch upstream
git merge --ff-only upstream/main
git push origin main
git switch dev
git merge --ff-only upstream/dev
git push origin dev
git switch -c feat/describe-the-change
```

Expand Down Expand Up @@ -120,11 +120,16 @@ git commit -m "Describe the change"
git push -u origin <your-branch-name>
```

Open a pull request from your fork branch to `procoderji-com/procoderji:main`. Fill in the
Open a pull request from your fork branch to `procoderji-com/procoderji:dev`. Fill in the
template, link a related issue when one exists, explain the resulting behavior, and list the
checks you ran.
Include screenshots for visible UI changes and sources or permissions for learning content.

`dev` is the integration/staging branch; `main` is production. Feature and dependency changes
go through `dev`. Maintainers promote the canonical `dev` branch to `main` after staging checks;
contributors do not send feature branches directly to `main`. See the
[branch and deployment workflow](docs/branching-and-releases.md).

Respond to review feedback and keep the branch up to date when needed. Maintainers review
student value, correctness, accessibility, and maintenance cost before merging. A proposed
feature may need a smaller scope or further discussion.
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ synchronization is a separate capability; paid billing is future work.
| Import a portfolio from a text template | [Markdown import](portfolio-markdown.md), [template](../public/templates/portfolio.md) |
| Work on portfolio fields, policy, publication, or storage | [Shared rules](../shared/portfolio/README.md), [Portfolio API](portfolio-api.md) |
| Configure production hosting and the site domain | [Deployment](deployment.md) |
| Contribute to dev, test staging and promote a production release | [Branches and releases](branching-and-releases.md) |
| Run the API and choose a database with Docker | [Containers](containers.md) |
| Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) |
| Report a vulnerability privately | [Security policy](../SECURITY.md) |
Expand Down
90 changes: 90 additions & 0 deletions docs/branching-and-releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Branches and deployments

ProCoderJii uses two long-lived branches:

| Branch | Purpose | Deployment |
| --- | --- | --- |
| `dev` | Default contribution branch; reviewed features and dependency updates integrate here. | Staging, after CI passes. |
| `main` | Reviewed production releases. | Production, after CI passes. |

The flow is `feature/* -> PR -> dev -> staging checks -> PR -> main`. There is no permanent
`release` branch. Version tags can identify releases without creating another integration branch.

## Contributions

Create a feature branch from current `dev` in your fork and open the PR against canonical `dev`.
Both branches reject deletion and force pushes and require PR review and passing checks.
The Branch policy check rejects a feature/fork PR directly into `main`; only this repository's
`dev` branch can be promoted. A fork branch named `dev` is not a production promotion.

Commits and contribution PRs use the contributor account. For the project owner's two-account
workflow, contributions use `Coding-Professional`; reviews and merges use `procoder-ji`.

## Promote a release

1. Make sure `dev` includes current `main`. If production has advanced independently, open a
`main -> dev` synchronization PR and run checks before testing staging again.
2. Wait for the exact `dev` commit's CI and staging deployment to pass. Exercise the changed
student journey on staging with sample data. Check migrations, account/portfolio privacy,
mobile and keyboard behavior when affected.
3. Open a PR from **canonical `dev` to `main`**, describing the behavior being released and
the staging checks. Review as `procoder-ji` and satisfy all required checks.
4. Use a **merge commit** for this promotion, preserving ancestry between the long-lived
branches. Squash merges remain suitable for individual feature PRs into `dev`.
5. Production deploys after the new `main` commit passes CI. Confirm the deployment summary,
health/session checks, canonical redirects and affected public routes.

Do not deploy unreviewed feature commits to production. Urgent fixes follow the same route;
there is no permanent bypass branch. Database schema changes remain deliberate release
operations: test the migrations on staging and apply approved production migrations with the
direct connection before rolling out code that requires them. The runtime keeps migration on
startup disabled. Never use a production database to run browser fixtures.

## Deployment environments

Each GitHub environment has branch restrictions and separate project-scoped deployment tokens.
The CI deployment job runs only on canonical `dev`/`main` pushes, after frontend, browser/SEO
and backend checks pass. PRs and fork workflows receive no deployment credentials.

| Environment | Allowed branch | Frontend project | Backend project |
| --- | --- | --- | --- |
| `staging` | `dev` | `procoderji-dev` | `procoderji-api-dev` |
| `production` | `main` | `procoderji` | `procoderji-api` |

Environment variables: `VERCEL_ORG_ID`, `VERCEL_FRONTEND_PROJECT_ID`,
`VERCEL_BACKEND_PROJECT_ID`, `PUBLIC_ORIGIN`, and `BACKEND_ORIGIN`.
Environment secrets: `VERCEL_FRONTEND_TOKEN` and `VERCEL_BACKEND_TOKEN`. Scope each token to
its project, give it an expiration date and rotate it before expiry. Tokens are never printed
or committed. The script passes them directly to the CLI child process without a shell or
command echo; it never dumps provider output into the workflow logs.
Database and Google credentials stay in the appropriate Vercel project's secret settings.

Staging uses a schema-only Neon branch and a fresh `procoderji_dev` database, with a separate
runtime role. Production student records and Data Protection keys are not copied into it.
Use a separate Google OAuth client with the staging frontend callback. Until configured,
staging supports guest practice and editor previews while account saving is unavailable.

Set `DEPLOYMENT_ENVIRONMENT=staging` in the staging frontend. Staging and Vercel previews send
`noindex, nofollow` and disallow crawling; a dedicated staging project is still a production
*build*, so `NODE_ENV` alone is not an environment boundary. Indexing directives are not access
control. Use sample data and configure hosting access protection when private QA is needed.

The deployment script copies tracked source only. It prepares the backend's service configuration
in an isolated directory, deploys/verifies the API first, then deploys the frontend. The live
summary records the checked source commit and both origins. A failure stops the rollout; inspect
the relevant Vercel project and restore its previous deployment if a rollback is needed.

Disable a previous independent Git deployment connection after this pipeline has been verified;
otherwise a second deployment path can publish before CI finishes. The canonical GitHub Actions
pipeline does not require granting Vercel's GitHub integration access to an organization.

## Operator recovery

For a manual staging check from a trusted checkout, set the staging project IDs and origins in
the current process, authenticate Vercel CLI, and run
`node scripts/deploy-vercel.mjs --local-staging`. This mode is unavailable in GitHub Actions and
cannot select production. Source files and the operator's existing auth remain separate.

For a production incident, use the hosting dashboard's rollback to the previous verified
deployment, then send the fix through `dev`. Do not delete student records or blindly reverse a
schema migration as part of an application rollback.
3 changes: 2 additions & 1 deletion docs/maintainer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ These steps do not need repeated owner permission. Existing decisions, including
backend and selected Neon PostgreSQL deployment, remain valid. A person implementing that
approved scope should continue through a reviewable change and its relevant checks.

Use the pull-request workflow for changes to `main`, and satisfy the repository's active review
Use feature PRs into `dev`, then a reviewed promotion PR from canonical `dev` into `main`.
Follow [branching and releases](branching-and-releases.md), and satisfy the active review
and status-check rules before merging. Maintainers can merge routine changes within their
authorised scope once the relevant checks and review are complete. Production rollout and
private-credential access follow the separately agreed operating scope.
Expand Down
6 changes: 5 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { isNonProductionDeployment } from "./src/lib/deployment.ts";

/**
* Conservative response headers. No Content-Security-Policy yet: the site loads
Expand All @@ -16,7 +17,10 @@ const nextConfig: NextConfig = {
// Do not announce the framework in an "X-Powered-By: Next.js" response header.
poweredByHeader: false,
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
const headers = isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV)
? [...securityHeaders, { key: "X-Robots-Tag", value: "noindex, nofollow" }]
: securityHeaders;
return [{ source: "/:path*", headers }];
},
// The old plain /courses/... reader duplicated the dashboard (same URL structure, no styling).
// Send visitors and any old links to the real pages instead of serving duplicate content.
Expand Down
Loading