Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/blog-readme-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ jobs:
git diff --quiet README.md || (
git add README.md &&
git commit -m "docs: sync Dev Blog section from docs/blog/index.md [skip ci]" &&
git push
git push origin HEAD:dev
)
22 changes: 19 additions & 3 deletions .github/workflows/squad-mark-released.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ on:
types: [published, released]
workflow_dispatch: {}

# NOTE: The default GITHUB_TOKEN cannot access GitHub Projects V2 via GraphQL.
# This workflow requires a repository secret named GH_PROJECT_TOKEN set to a
# classic Personal Access Token (PAT) with the `project` OAuth scope.
# See: https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects
permissions:
repository-projects: write
contents: read

env:
PROJECT_ID: PVT_kwHOA5k0b84BVFTy
Expand All @@ -21,10 +25,22 @@ jobs:
runs-on: ubuntu-latest

steps:
- name: Validate GH_PROJECT_TOKEN secret is configured
env:
TOKEN_CHECK: ${{ secrets.GH_PROJECT_TOKEN }}
run: |
if [ -z "$TOKEN_CHECK" ]; then
echo "::error::GH_PROJECT_TOKEN secret is not set."
echo "::error::Add a classic PAT with the 'project' OAuth scope as a repository secret named GH_PROJECT_TOKEN."
echo "::error::See: Settings → Secrets and variables → Actions → New repository secret"
exit 1
fi
echo "✅ GH_PROJECT_TOKEN secret is present."

- name: Move Done → Released on project board
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ secrets.GH_PROJECT_TOKEN }}
script: |
const PROJECT_ID = process.env.PROJECT_ID;
const STATUS_FIELD_ID = process.env.STATUS_FIELD_ID;
Expand Down
32 changes: 32 additions & 0 deletions .squad/agents/boromir/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,38 @@

## Learnings

### 2026-05-XX — Issue #269: Blog → README Sync workflow branch protection fix

**Problem:** `blog-readme-sync.yml` pushed directly to `main` after updating `README.md`, which is blocked by branch protection rules (direct pushes forbidden, "Build Solution" check required).

**Fix (Option C):** Changed `git push` to `git push origin HEAD:dev` in the "Commit updated README" step. The workflow still triggers on `push: branches: [main]` (reading `docs/blog/index.md` from main), but the README update is pushed to `dev` — the normal development branch — and flows through the standard dev→main release cycle.

**Key insight:** The `permissions: contents: write` block was already present. No new secrets or PAT bypass needed. One-line change.

**Decision:** Captured in `.squad/decisions/inbox/boromir-269-readme-sync-target.md`.
### 2026-05-08 — Issue #268: Fix squad-mark-released GraphQL Permission Error
Comment on lines +51 to +60

**Root cause:**
The `permissions: repository-projects: write` block in the workflow was incorrect — it applies only to `GITHUB_TOKEN`, not to a custom PAT. The workflow uses `${{ secrets.GH_PROJECT_TOKEN }}`, but:

1. If the secret is not set, `actions/github-script` receives an empty string and falls back to `GITHUB_TOKEN`
2. `GITHUB_TOKEN` cannot access GitHub Projects V2 GraphQL API, producing `Resource not accessible by integration`
3. Even if set, the PAT needs the `project` OAuth scope (classic PAT) for Projects V2 mutations

**What was fixed:**

1. Changed `permissions: repository-projects: write` → `permissions: contents: read` (correct for a workflow that only uses a custom PAT — no GITHUB_TOKEN escalation needed)
2. Added a pre-flight validation step that explicitly checks `GH_PROJECT_TOKEN` is set, failing early with an actionable error message including setup instructions
3. Downgraded `actions/github-script@v9` → `@v7` (stable LTS version)
4. Added a top-of-file comment documenting the required PAT scope (`project`)

**Key lesson:** For GitHub Projects V2 GraphQL, `GITHUB_TOKEN` is never sufficient regardless of `permissions` block settings. A classic PAT with `project` OAuth scope (or fine-grained PAT with Projects read/write) is required. Always add a pre-flight secret validation step so failures are immediately actionable.

**Files changed:** `.github/workflows/squad-mark-released.yml`
**Related decisions inbox:** `boromir-268-project-token.md`

---

### 2026-05-08 — Issue #249: AppHost Mongo Clear Hardening

**What was done:**
Expand Down
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"cSpell.words": [
"ASPNETCORE",
"cref",
"EECOM",
"inheritdoc",
"msbuild",
"rendermode",
"reskill",
Expand Down
129 changes: 1 addition & 128 deletions src/AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@
//Solution Name : MyBlog
//Project Name : AppHost
//=======================================================
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;

using MongoDB.Bson;
using MongoDB.Driver;

var builder = DistributedApplication.CreateBuilder(args);

Expand All @@ -19,129 +14,7 @@
var mongoDb = mongo.AddDatabase("myblog");
var redis = builder.AddRedis("redis");

// AC2 (#249): Semaphore prevents overlapping clear runs. A second concurrent invocation
// returns immediately with operator feedback instead of racing against the first.
var clearMutex = new SemaphoreSlim(1, 1);

// Expose the destructive clear-data action only during local runs (IsRunMode = false when publishing).
if (builder.ExecutionContext.IsRunMode)
{
mongo.WithCommand(
"clear-myblog-data",
"⚠️ Clear MyBlog Data",
executeCommand: async context =>
{
// AC2: Non-blocking acquire — return immediately if another clear is already in flight.
if (!await clearMutex.WaitAsync(0))
{
context.Logger.LogWarning(
"Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.",
context.ResourceName);

return new ExecuteCommandResult
{
Success = false,
Message = "A clear operation is already in progress. Wait for the current run to finish, then try again."
};
}

try
{
context.Logger.LogWarning(
"Clear MyBlog data invoked on {ResourceName} — enumerating collections in 'myblog'.",
context.ResourceName);

var connectionString = await mongo.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken);
if (connectionString is null)
{
context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName);
return new ExecuteCommandResult
{
Success = false,
Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?"
};
}

var client = new MongoClient(connectionString);
var database = client.GetDatabase("myblog");

var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken);
var collectionNames = await namesCursor.ToListAsync(context.CancellationToken);

var results = new List<(string Name, long Deleted)>();
var warnings = new List<string>();

foreach (var name in collectionNames)
{
// Skip MongoDB internal system collections (e.g. system.views, system.users).
if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase))
continue;

try
{
// AC3 (#249): Best-effort per collection — errors are caught, logged as warnings,
// and the loop continues so remaining collections are still processed.
var collection = database.GetCollection<BsonDocument>(name);
var deleteResult = await collection.DeleteManyAsync(
FilterDefinition<BsonDocument>.Empty,
context.CancellationToken);

results.Add((name, deleteResult.DeletedCount));

context.Logger.LogInformation(
"Collection '{Collection}': {Count} document(s) deleted.",
name, deleteResult.DeletedCount);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var warning = $"{name}: {ex.Message}";
warnings.Add(warning);
context.Logger.LogWarning(
ex,
"Collection '{Collection}' could not be cleared — skipping and continuing.",
name);
}
}

var totalDeleted = results.Sum(static r => r.Deleted);
var perCollection = results.Count == 0
? "no non-system collections found"
: string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}"));

context.Logger.LogWarning(
"Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.",
totalDeleted, results.Count, warnings.Count);

var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})";
if (warnings.Count > 0)
message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}";

return new ExecuteCommandResult
{
Success = true,
Message = message
};
}
finally
{
clearMutex.Release();
}
},
new CommandOptions
{
Description = "Permanently deletes all data from the myblog database. Local development only.",
ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?",
IsHighlighted = true,
IconName = "DatabaseWarning",
// AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT
// check dependent resources (Web, etc.). Clearing is valid while the app is live against
// local Mongo; the Web app running is not a reason to disable the command.
UpdateState = ctx =>
ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled
});
}
mongo.WithMongoDbDevCommands("myblog");

builder.AddProject<Projects.Web>("web")
.WithReference(mongoDb)
Expand Down
Loading
Loading