Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6459eae
ci: add protected dev and main deployment workflow
Coding-Professional Nov 13, 2026
bf41955
fix(ci): authenticate native Vercel deployments with scoped tokens
Coding-Professional Nov 13, 2026
e0eb2da
Merge pull request #84: dev and main deployment workflow
procoder-ji Nov 13, 2026
cb085f7
feat(ui): redesign the student learning experience
Coding-Professional Nov 13, 2026
2a1f0e0
perf(ui): remove the unused motion runtime
Coding-Professional Nov 13, 2026
be848fe
fix(ci): verify live deployment readiness before release checks
Coding-Professional Nov 13, 2026
eb9933d
test(e2e): assert portfolio readiness without network-idle waits
Coding-Professional Nov 13, 2026
fb1f877
Merge pull request #88: student interface refresh
procoder-ji Nov 13, 2026
b34733a
fix(ui): restore original branding and improve mobile usability
Coding-Professional Nov 13, 2026
4251a7c
docs(ui): show the preserved brand on mobile
Coding-Professional Nov 13, 2026
81f6a24
fix(ui): restore original navbar and simplify semester cards
Coding-Professional Nov 13, 2026
8cc72a5
feat(contact): save private messages with verified CAPTCHA
Coding-Professional Nov 13, 2026
9ae876c
fix(ui): unify student navigation and refresh README previews
Coding-Professional Nov 13, 2026
7f8a918
Merge pull request #95 from Coding-Professional/fix/shared-site-header
procoder-ji Nov 13, 2026
32bc194
fix(ci): discover deployments from exact run metadata
Coding-Professional Nov 13, 2026
8bb0244
fix(ci): activate and verify the checked deployment target
Coding-Professional Nov 13, 2026
c933a9a
Merge pull request #97 from Coding-Professional/fix/release-deploymen…
procoder-ji Nov 13, 2026
e1c9c4d
fix(ci): avoid conflicting promotion of current deployments
Coding-Professional Nov 13, 2026
6d1ed12
Merge pull request #99 from Coding-Professional/fix/idempotent-releas…
procoder-ji Nov 13, 2026
62f2840
fix(ci): accept empty successful promotion responses
Coding-Professional Nov 13, 2026
44ff7fb
Merge pull request #100 from Coding-Professional/fix/empty-promotion-…
procoder-ji Nov 13, 2026
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ YOUTUBE_CHANNEL_ID=UC74q2pzSGuoaikYwnavcGMg
# Google client secrets belong in .NET user-secrets or the backend host's secret configuration.
# See backend/README.md for Google OAuth, database setup, and deployment.
# DOTNET_API_URL=http://localhost:5100

# Required for direct contact-form submissions: same server-only secret as backend Contact__ProxyKey.
# CONTACT_PROXY_KEY=
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
48 changes: 46 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 @@ -40,6 +41,9 @@ jobs:
- name: Unit tests
run: npm test

- name: Deployment gate tests
run: node --test scripts/*.test.mjs

- name: Production build
run: npm run build

Expand Down Expand Up @@ -148,3 +152,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
19 changes: 14 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 @@ -114,17 +114,26 @@ any that could not run; maintainers and CI complete the remaining validation.

## Submit the pull request

Keep the existing yellow/black visual identity while core student features are being
completed. Improve responsiveness, usability and consistency within that design.
A broad website redesign is deferred until the core student journeys are working.

```bash
git add <files-you-changed>
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ A student learning workspace with original practice questions, explanations, and
> [!NOTE]
> This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in, account progress, and private portfolio drafts. The portfolio editor, two themes, Markdown import, and public-page support are implemented; public publishing is off by default pending deployment configuration. Billing and checkout are not enabled. Course notes, guides, and job listings are awaiting published content.

![Guest learning workspace before the first session](docs/images/learning-workspace.png)
![ProCoderJii homepage with the current yellow brand and shared navigation](docs/images/homepage-desktop.png)

<details>
<summary>Learning workspace and mobile previews</summary>

![Guest learning workspace with the shared navigation and dashboard sidebar](docs/images/learning-workspace.png)

<img src="docs/images/responsive-home-mobile.png" alt="ProCoderJii homepage at 360px" width="360" />
<img src="docs/images/responsive-menu-mobile.png" alt="Shared mobile navigation at 360px" width="360" />

</details>

See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work.

Expand Down
4 changes: 4 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, durable
and portfolio drafts/publication. Next.js forwards `/api/platform/*` through its server-only
BFF. Guest practice and the in-memory portfolio preview work without backend credentials.

The anonymous [contact form](../docs/contact-form.md) saves private messages after server-side
Cloudflare Turnstile verification. It requires separate CAPTCHA and proxy credentials; Google
sign-in is not required. Apply its migration before enabling submission.

## Run locally

Install a supported .NET 10 SDK and keep its runtime on the latest servicing patch. From the repository root:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using ProCoderJi.Api.Features.Practice;
using ProCoderJi.Api.Features.Progress;
using ProCoderJi.Api.Features.Portfolio;
using ProCoderJi.Api.Features.Contact;

namespace ProCoderJi.Api.Data.Mongo;

Expand All @@ -16,10 +17,16 @@ public sealed class MongoPlatformDatabase(IMongoDatabase database)
public IMongoCollection<MongoUserDocument> Users => Database.GetCollection<MongoUserDocument>("users");
public IMongoCollection<MongoProgressDocument> Progress => Database.GetCollection<MongoProgressDocument>("progress");
public IMongoCollection<MongoKeyDocument> Keys => Database.GetCollection<MongoKeyDocument>("dataProtectionKeys");
public IMongoCollection<ContactMessage> ContactMessages => Database.GetCollection<ContactMessage>("contactMessages");

/// <summary>MongoDB has no EF migrations; provisioning creates the identity and username boundaries.</summary>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await ContactMessages.Indexes.CreateManyAsync([
new CreateIndexModel<ContactMessage>(Builders<ContactMessage>.IndexKeys.Ascending(message => message.CaptchaTokenHash),
new CreateIndexOptions { Unique = true, Name = "IX_contactMessages_CaptchaTokenHash" }),
new CreateIndexModel<ContactMessage>(Builders<ContactMessage>.IndexKeys.Ascending(message => message.CreatedAtUtc))
], cancellationToken: cancellationToken);
await Users.Indexes.CreateManyAsync([
new CreateIndexModel<MongoUserDocument>(Builders<MongoUserDocument>.IndexKeys.Ascending(user => user.GoogleSubject),
new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }),
Expand Down
13 changes: 13 additions & 0 deletions backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ProCoderJi.Api.Features.Portfolio;
using ProCoderJi.Api.Features.Contact;

namespace ProCoderJi.Api.Data;

Expand All @@ -10,10 +11,22 @@ public sealed class PlatformDbContext(DbContextOptions<PlatformDbContext> option
public DbSet<ProgressState> ProgressStates => Set<ProgressState>();
public DbSet<PracticeAttemptEntity> Attempts => Set<PracticeAttemptEntity>();
public DbSet<PortfolioEntity> Portfolios => Set<PortfolioEntity>();
public DbSet<ContactMessage> ContactMessages => Set<ContactMessage>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<ContactMessage>(entity =>
{
entity.HasKey(message => message.Id);
entity.Property(message => message.Name).HasMaxLength(100);
entity.Property(message => message.Email).HasMaxLength(254);
entity.Property(message => message.Subject).HasMaxLength(160);
entity.Property(message => message.Message).HasMaxLength(4000);
entity.Property(message => message.CaptchaTokenHash).HasMaxLength(64);
entity.HasIndex(message => message.CaptchaTokenHash).IsUnique();
entity.HasIndex(message => message.CreatedAtUtc);
});
model.Entity<LearnerUser>(entity =>
{
entity.HasKey(user => user.Id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.RateLimiting;
using ProCoderJi.Api.Configuration;

namespace ProCoderJi.Api.Features.Contact;

public sealed class ContactConfiguration(IConfiguration configuration, PlatformConfiguration platform)
{
public string SiteKey => configuration["Contact:TurnstileSiteKey"]?.Trim() ?? "";
public string SecretKey => configuration["Contact:TurnstileSecretKey"]?.Trim() ?? "";
private string ProxyKey => configuration["Contact:ProxyKey"] ?? "";
public string Hostname => platform.PublicOrigin.Host;
public bool Configured => SiteKey.Length > 0 && SecretKey.Length > 0 && ProxyKey.Length >= 32 &&
!System.Text.RegularExpressions.Regex.IsMatch(SiteKey, "^[123]x0{20}[A-Z]{2}$") &&
!System.Text.RegularExpressions.Regex.IsMatch(SecretKey, "^[123]x0{31}AA$");

// Only the trusted BFF may supply a client address. Browser headers never select a limiter partition.
public bool IsTrusted(HttpContext context)
{
var supplied = context.Request.Headers["X-Contact-Proxy-Key"].ToString();
return Configured && supplied.Length == ProxyKey.Length && CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(supplied), Encoding.UTF8.GetBytes(ProxyKey));
}

public string? ClientIp(HttpContext context) => IsTrusted(context) &&
IPAddress.TryParse(context.Request.Headers["X-Contact-Client-IP"].ToString(), out var address)
? address.ToString() : null;
}

public static class ContactRegistration
{
public const string RatePolicy = "contact-submit";
public static IServiceCollection AddContactApi(this IServiceCollection services)
{
services.AddSingleton<ContactConfiguration>();
services.AddHttpClient<ITurnstileVerifier, TurnstileVerifier>(client => client.Timeout = TimeSpan.FromSeconds(8));
services.AddRateLimiter(options => options.AddPolicy(RatePolicy, context =>
{
var ip = context.RequestServices.GetRequiredService<ContactConfiguration>().ClientIp(context);
return ip is null ? RateLimitPartition.GetNoLimiter("untrusted-contact") :
RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0,
AutoReplenishment = true
});
}));
return services;
}
}
Loading